Passed
Push — main ( 0f249b...e7fb5c )
by Eduardo
02:45 queued 12s
created

SoFloC   A

Complexity

Total Complexity 15

Size/Duplication

Total Lines 136
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 15
eloc 97
dl 0
loc 136
c 0
b 0
f 0
rs 10
ccs 61
cts 61
cp 1

4 Functions

Rating   Name   Duplication   Size   Complexity  
A load 0 28 4
A copyFlow 0 31 4
A deleteFlow 0 25 3
A updateVersion 0 21 3
1 1
import { randomUUID } from 'crypto'
2 1
import JSZip from 'jszip'
3 1
import { xml2js } from 'xml-js'
4
import { CustomisationsXml } from './customisations'
5
import { SolutionXml } from './solution'
6
import { Base64, FileInput, FlowCopyT, PrivateWorkflowT, WorkflowT, Xml } from './types'
7
8 1
export * from './types'
9
10 1
export class SoFloC {
11
  /**
12
   * Creates a new SoFloC instance. To be able to use it you need to run `await soFloC.load()`
13
   * @param file The file data to be open
14
   * @param name The name of the file
15
   */
16 17
  constructor (file: FileInput, name: string) {
17 17
    this.#wasLoaded = false
18 17
    this.#file = file
19 17
    this.name = name
20
  }
21
22
  /**
23
   * Loads a ***Solution*** zip file and make it ready to get the existing flows and the version, copy flows and update the version. Sets #wasLoaded to true
24
   */
25 19
  async load () {
26 19
    if (!this.#wasLoaded) {
27 16
      try {
28 16
        this.#zip = await this.#unzip(this.#file)
29
30 14
        const [customisations, customisationsData] = await this.#getCustomisations(this.#zip)
31 13
        this.#customisations = customisations
32 13
        this.#customisationsData = customisationsData
33
34 13
        const [solution, solutionData] = await this.#getSolution(this.#zip)
35 12
        this.#solution = solution
36 12
        this.#solutionData = solutionData
37
38 12
        this.version = this.#getCurrentVersion(this.#solutionData)
39 11
        this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
40 11
        this.data = await this.#getData(this.#zip)
41
42 11
        this.#wasLoaded = true
43
      } catch (error) {
44 5
        if (typeof error === 'string') {
45 5
          throw new Error(error)
46
        }
47
        /* istanbul ignore next */
48
        throw error
49
      }
50
    }
51
  }
52
53
  /**
54
   * Copies a flow in the ***Solution***.
55
   * @param flowGuid The GUID of the flow to be copied
56
   * @param newFlowName The name of the copy
57
   * @param newVersion The new ***Solution*** version
58
   */
59 7
  async copyFlow (flowGuid: string, newFlowName: string, newVersion?: string) {
60 7
    await this.load()
61 6
    try {
62 6
      this.#worflowExists(flowGuid)
63
64 3
      if (newVersion) await this.updateVersion(newVersion)
65
66 3
      const copyData = this.#getCopyData(newFlowName)
67
68 3
      const [customisations, customisationsData] = this.#copyOnCustomisations(flowGuid, copyData)
69 3
      this.#customisations = customisations
70 3
      this.#customisationsData = customisationsData
71
72 3
      const [solution, solutionData] = this.#copyOnSolution(flowGuid, copyData)
73 3
      this.#solution = solution
74 3
      this.#solutionData = solutionData
75
76 3
      await this.#copyFile(flowGuid, copyData)
77
    } catch (error) {
78 3
      if (typeof error === 'string') {
79 3
        throw new Error(error)
80
      }
81
      /* istanbul ignore next */
82
      throw error
83
    }
84
  }
85
86
  /**
87
   * Deletes a flow in the ***Solution***.
88
   * @param flowGuid The GUID of the flow to be copied
89
   */
90 5
  async deleteFlow (flowGuid: string) {
91 5
    await this.load()
92 4
    try {
93 4
      this.#worflowExists(flowGuid)
94
95 1
      const [customisations, customisationsData] = this.#deleteOnCustomisations(flowGuid)
96 1
      this.#customisations = customisations
97 1
      this.#customisationsData = customisationsData
98
99 1
      const [solution, solutionData] = this.#deleteOnSolution(flowGuid)
100 1
      this.#solution = solution
101 1
      this.#solutionData = solutionData
102
103 1
      await this.#deleteFile(flowGuid)
104
    } catch (error) {
105 3
      if (typeof error === 'string') {
106 3
        throw new Error(error)
107
      }
108
      /* istanbul ignore next */
109
      throw error
110
    }
111
  }
112
113
  /**
114
   * Updates the ***Solution*** version. The new version must be bigger than the previous.
115
   * @param newVersion The new ***Solution*** version
116
   */
117 7
  async updateVersion (newVersion: string) {
118 7
    await this.load()
119 4
    try {
120 4
      this.#validateVersion(newVersion)
121
122 2
      this.name = this.name
123
        .replace(this.#snake(this.version), this.#snake(newVersion))
124 2
      this.#solution = this.#solution
125
        .replace(`<Version>${this.version}</Version>`, `<Version>${newVersion}</Version>`)
126 2
      this.version = newVersion
127
    } catch (error) {
128 2
      if (typeof error === 'string') {
129 2
        throw new Error(error)
130
      }
131
      /* istanbul ignore next */
132
      throw error
133
    }
134
  }
135
136
  /**
137
   * The list of workflows in the solution. To be able to get the list you need to run `await soFloC.load()` first.
138
   */
139 4
  get workflows () {
140 4
    if (!this.#wasLoaded) return []
141 8
    return this.#workflows.map(workflow => ({
142
      name: workflow.name,
143
      id:   workflow.id,
144
    })) as WorkflowT[]
145
  }
146
147
  /* #region LOAD METHODS */
148
  /**
149
   * Resets the loaded data
150
   */
151
  /**
152
   * Retrieves the ***Solution*** zip content
153
   * @param file The ***Solution*** zip file (base64, string, text, binarystring, array, uint8array, arraybuffer, blob or stream)
154
   */
155
  async #unzip (file: FileInput) {
156 16
    try {
157 16
      const options = typeof file === 'string'
158
        ? { base64: true }
159
        : {}
160 16
      return await JSZip.loadAsync(file, options)
161
    } catch (error) {
162 2
      console.log(error)
163 2
      throw 'Failed to unzip the file'
164
    }
165
  }
166
167
  /**
168
   * Retrieves the customization.xml string
169
   * @param zip The ***Solution*** JSZip content
170
   */
171
  async #getCustomisations (zip: JSZip): Promise<[Xml, CustomisationsXml]> {
172 14
    return (await this.#getXmlContentFromZip('customizations', zip)) as [Xml, CustomisationsXml]
173
  }
174
175
  /**
176
   * Retrieves the customization.xml string
177
   * @param zip The ***Solution*** JSZip content
178
   */
179
  async #getSolution (zip: JSZip): Promise<[Xml, SolutionXml]> {
180 13
    return (await this.#getXmlContentFromZip('solution', zip)) as [Xml, SolutionXml]
181
  }
182
183
  /**
184
   * Retrieves a XML from the ***Solution*** zip.
185
   * @param xmlName The name of the XML to be retrieved (without extension)
186
   * @returns The string content of the XML
187
   */
188
  async #getXmlContentFromZip (xmlName: string, zipContents: JSZip): Promise<[Xml, CustomisationsXml | SolutionXml]> {
189 27
    try {
190 27
      const file = zipContents.files[`${xmlName}.xml`]
191 27
      const xml = await file.async('string')
192 25
      const data = xml2js(xml, { compact: true }) as CustomisationsXml
193
194 25
      return [
195
        xml,
196
        data,
197
      ]
198
    } catch (error) {
199 2
      console.log(error)
200 2
      throw `'${xmlName}.xml' was not found in the Solution zip`
201
    }
202
  }
203
204
  /**
205
   * Retrieves the ***Solution*** current version from solution.xml
206
   * @param solution The solution.xml
207
   */
208
  #getCurrentVersion (solution: SolutionXml) {
209 12
    try {
210 12
      return solution.ImportExportXml.SolutionManifest.Version._text
211
    } catch (error) {
212 1
      console.log(error)
213 1
      throw 'Failed to retrieve the version'
214
    }
215
  }
216
217
  /**
218
   * Retrieves the list of workflows found in the ***Solution*** zip
219
   * @param customisations The customizations.xml
220
   * @param zip The ***Solution*** JSZip content
221
   * @returns The workflows list
222
   */
223
  #getWorkflows (customisations: CustomisationsXml, solution: SolutionXml, zip: JSZip) {
224 33
    const workflowFiles = Object.entries(zip.files).filter(([name]) => name.match(/Workflows\/.+\.json/)).map(file => file[1])
225
226 15
    const wfs = Array.isArray(customisations.ImportExportXml.Workflows.Workflow)
227
      ? customisations.ImportExportXml.Workflows.Workflow
228
      : [customisations.ImportExportXml.Workflows.Workflow]
229 15
    const workflows = wfs
230 35
      .map(workflow => {
231 35
        const id = workflow._attributes.WorkflowId.replace(/{|}/g, '')
232 35
        const rcs = Array.isArray(solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent)
233
          ? solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent
234
          : [solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent]
235 69
        const isOnSolution = rcs.findIndex(wf => wf._attributes.id.includes(id)) >= 0
236 65
        const file = workflowFiles.find(workflowFile => workflowFile.name.includes(id.toUpperCase())) as JSZip.JSZipObject
237 35
        return !!file && !!id && isOnSolution
238
          ? {
239
              name: workflow._attributes.Name,
240
              id,
241
              file,
242
            }
243
          : null
244
      })
245 35
    return workflows.filter(workflow => workflow !== null) as PrivateWorkflowT[]
246
  }
247
248
  /**
249
   * Retrieves the zip data
250
   * @param zip The ***Solution*** zip
251
   * @returns The generated base64 zip
252
   */
253
  async #getData (zip: JSZip) {
254 15
    return await zip.generateAsync({
255
      type:               'base64',
256
      compression:        'DEFLATE',
257
      compressionOptions: {
258
        level: 9,
259
      },
260
    })
261
  }
262
  /* #endregion */
263
264
  /* #region COPY FLOW METHODS */
265
  /**
266
   * Retrieves an object containing the information of the flow copy
267
   * @param newFlowName The name of the flow copy
268
   * @returns The flow copy data
269
   */
270
  #getCopyData (newFlowName: string) {
271 3
    const guid = randomUUID()
272 3
    const upperGuid = guid.toUpperCase()
273 3
    const fileName = `Workflows/${newFlowName.replace(/\s/g, '')}-${upperGuid}.json`
274
275 3
    return {
276
      guid,
277
      upperGuid,
278
      name: newFlowName,
279
      fileName,
280
    }
281
  }
282
283
  /**
284
   * Copies the flow inside the customizations.xml
285
   * @param flowGuid The GUID of the original flow to be copied
286
   * @param copyData The data of the flow copy
287
   * @returns The customisations.xml data
288
   */
289
  #copyOnCustomisations (flowGuid: string, copyData: FlowCopyT): [Xml, CustomisationsXml] {
290 3
    const workflowComponent = `<Workflow WorkflowId="{${flowGuid}}" Name=".+?">(.|\r|\n)+?<\/Workflow>`
291 3
    const workflowRegEx = new RegExp(`\r?\n?.+?${workflowComponent}`, 'gm')
292
293 3
    const workflow = this.#customisations.match(workflowRegEx)?.[0] as string
294
295 3
    const jsonFileNameRegEx = /<JsonFileName>(.|\r|\n)+?<\/JsonFileName>/gi
296 3
    const introducedVersionRegEx = /<IntroducedVersion>(.|\r|\n)+?<\/IntroducedVersion>/gi
297
298 3
    const copy = workflow
299
      .replace(flowGuid, copyData.guid)
300
      .replace(/Name=".+?"/, `Name="${copyData.name}"`)
301
      .replace(jsonFileNameRegEx, `<JsonFileName>/${copyData.fileName}</JsonFileName>`)
302
      .replace(introducedVersionRegEx, `<IntroducedVersion>${this.version}</IntroducedVersion>`)
303
304 3
    const customisations = this.#customisations.replace(workflow, `${workflow}${copy}`)
305 3
    const data = xml2js(customisations, { compact: true }) as CustomisationsXml
306
307 3
    return [
308
      customisations,
309
      data,
310
    ]
311
  }
312
313
  /**
314
   * Copies the flow inside solution.xml
315
   * @param flowGuid The GUID of the original flow to be copied
316
   * @param copyData The data of the flow copy
317
   * @returns The solution.xml data
318
   */
319
  #copyOnSolution (flowGuid: string, copyData: FlowCopyT): [Xml, SolutionXml] {
320 3
    const rootComponent = `<RootComponent type="29" id="{${flowGuid}}" behavior="0" />`
321 3
    const rootRegEx = new RegExp(`\r?\n?.+?${rootComponent}`, 'gm')
322
323 3
    const root = this.#solution.match(rootRegEx)?.[0] as string
324
325 3
    const copy = root
326
      .replace(flowGuid, copyData.guid)
327
328 3
    const solution = this.#solution
329
      .replace(root, `${root}${copy}`)
330 3
    const data = xml2js(solution, { compact: true }) as SolutionXml
331
332 3
    return [
333
      solution,
334
      data,
335
    ]
336
  }
337
338
  /**
339
   * Copies the flow inside the ***Solution*** zip and updates data and #workflows properties
340
   * @param flowGuid The GUID of the original flow to be copied
341
   * @param copyData The data of the flow copy
342
   */
343
  async #copyFile (flowGuid: string, copyData: FlowCopyT) {
344 4
    const fileToCopy = this.#workflows.find(wf => wf.id === flowGuid.toLowerCase()) as PrivateWorkflowT
345
346 3
    this.#zip.file(copyData.fileName, await fileToCopy.file.async('string'))
347 3
    this.#zip.file('solution.xml', this.#solution)
348 3
    this.#zip.file('customizations.xml', this.#customisations)
349
350 3
    this.data = await this.#getData(this.#zip)
351 3
    this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
352
  }
353
  /* #endregion */
354
355
  /* #region DEPLETE FLOW METHODS */
356
  /**
357
   * Deletes the flow inside the customizations.xml
358
   * @param flowGuid The GUID of the flow to be deleted
359
   * @returns The customisations.xml data
360
   */
361
  #deleteOnCustomisations (flowGuid: string): [Xml, CustomisationsXml] {
362 1
    const workflowComponent = `<Workflow WorkflowId="{${flowGuid}}" Name=".+?">(.|\r|\n)+?<\/Workflow>`
363 1
    const workflowRegEx = new RegExp(`\r?\n?.+?${workflowComponent}`, 'gm')
364
365 1
    const workflow = this.#customisations.match(workflowRegEx)?.[0] as string
366
367 1
    const customisations = this.#customisations.replace(workflow, '')
368 1
    const data = xml2js(customisations, { compact: true }) as CustomisationsXml
369
370 1
    return [
371
      customisations,
372
      data,
373
    ]
374
  }
375
376
  /**
377
   * Deletes the flow inside solution.xml
378
   * @param flowGuid The GUID of the flow to be deleted
379
   * @returns The solution.xml data
380
   */
381
  #deleteOnSolution (flowGuid: string): [Xml, SolutionXml] {
382 1
    const rootComponent = `<RootComponent type="29" id="{${flowGuid}}" behavior="0" />`
383 1
    const rootRegEx = new RegExp(`\r?\n?.+?${rootComponent}`, 'gm')
384
385 1
    const root = this.#solution.match(rootRegEx)?.[0] as string
386
387 1
    const solution = this.#solution.replace(root, '')
388 1
    const data = xml2js(solution, { compact: true }) as SolutionXml
389
390 1
    return [
391
      solution,
392
      data,
393
    ]
394
  }
395
396
  /**
397
   * Deletes the flow inside the ***Solution*** zip and updates data and #workflows properties
398
   * @param flowGuid The GUID of the flow to be deleted
399
   */
400
  async #deleteFile (flowGuid: string) {
401 2
    const fileToDelete = this.#workflows.find(wf => wf.id === flowGuid.toLowerCase()) as PrivateWorkflowT
402
403 1
    this.#zip.remove(fileToDelete.file.name)
404 1
    this.#zip.file('solution.xml', this.#solution)
405 1
    this.#zip.file('customizations.xml', this.#customisations)
406
407 1
    this.data = await this.#getData(this.#zip)
408 1
    this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
409
  }
410
  /* #endregion */
411
412
  /* #region UPDATE VERION METHODS */
413
  /**
414
   * Validates if the new version is valid
415
   * @param newVersion The new ***Solution*** version
416
   */
417
  #validateVersion (newVersion: string) {
418 4
    const validRegEx = /^((\d+\.)+\d+)$/
419 4
    if (!validRegEx.exec(newVersion)) {
420 1
      throw `Version '${newVersion}' is not valid. It should follow the format <major>.<minor>.<build>.<revision>.`
421
    }
422
423 12
    const currentVersionValues = this.version.split('.').map(value => Number(value))
424 12
    const newVersionValues = newVersion.split('.').map(value => Number(value))
425
426 3
    let currentValueString = ''
427 3
    let newValueString = ''
428 3
    for (let i = 0; i < currentVersionValues.length; i++) {
429 12
      const currentValue = currentVersionValues[i]
430 12
      const newValue = newVersionValues[i]
431
432 12
      const currentValueLength = String(currentValue).length
433 12
      const newValueLength = String(newValue).length
434
435 12
      const maxLength = Math.max(currentValueLength, newValueLength)
436
437 12
      currentValueString += '0'.repeat(maxLength - currentValueLength) + String(currentValue)
438 12
      newValueString += '0'.repeat(maxLength - newValueLength) + String(newValue)
439
    }
440
441 3
    if (Number(newValueString) <= Number(currentValueString)) throw `Version '${newVersion}' is smaller than '${this.version}'`
442
  }
443
  /* #endregion */
444
445
  /* #region  GENERAL METHODS */
446
  /**
447
   * Verifies if a specified workflow exists in the ***Solution***
448
   */
449
  #worflowExists (flowGuid: string) {
450 12
    if (this.#workflows.findIndex(wf => wf.id === flowGuid) < 0) throw `Workflow file with GUID '${flowGuid}' does not exist in this Solution or the Solution was changed without updating 'solution.xml' or 'customizations.xml'`
451
  }
452
453
  /**
454
   * Retrieves the version replacing '.' to '_'
455
   * @param version The version to be converted to snake_case
456
   * @returns
457
   */
458
  #snake (version: string) {
459 4
    return version.replaceAll('.', '_')
460
  }
461
  /* #endregion */
462
463
  /* #region CLASS PROPERTIES */
464
  #file: FileInput
465
  #zip: JSZip
466
  /**
467
   * The ***Solution*** file name. It is update as a new version is set
468
   */
469
  name: string
470
  /**
471
   * The ***Solution*** version. It is update as a new version is set
472
   */
473
  version: string
474
  /**
475
   * The ***Solution*** data as Base64. It is updated as new copies are added.
476
   */
477
  data: Base64
478
  #workflows: PrivateWorkflowT[]
479
  #customisations: Xml
480
  #customisationsData: CustomisationsXml
481
  #solution: Xml
482
  #solutionData: SolutionXml
483 17
  #wasLoaded = false
484
485
  // TODO UndoStack
486
487
  /* #endregion */
488
}
489